Skip to content

Audience membership update - Core + multiple destinations#3658

Open
joe-ayoub-segment wants to merge 27 commits intomainfrom
audience-membership
Open

Audience membership update - Core + multiple destinations#3658
joe-ayoub-segment wants to merge 27 commits intomainfrom
audience-membership

Conversation

@joe-ayoub-segment
Copy link
Contributor

@joe-ayoub-segment joe-ayoub-segment commented Mar 10, 2026

Summary

This PR introduces a centralized audienceMembership resolution mechanism in actions-core and migrates five audience destinations to consume it. Instead of each destination independently parsing Engage
traits/properties or RETL sync-mode event names to determine add vs. remove, the Core framework now computes a typed boolean | undefined value and injects it into every action's ExecuteInput. All destination
changes are gated behind per-destination feature flags with full legacy fallback.


Core Changes (packages/core)

New: audience-membership.ts

Centralizes the logic for resolving audience membership from raw event data:

  • resolveAudienceMembership(rawData, syncMode) — top-level resolver; tries Engage first, then RETL
  • engageAudienceMembership(rawData) — reads context.personas.computation_class + computation_key from identify/track events emitted by Engage
  • retlAudienceMembership(rawData, syncMode) — maps RETL event names (new, updated, deleted) against the configured syncMode (add, update, upsert, mirror, delete)
  • Returns true (add), false (remove), or undefined (non-audience event / indeterminate)

New: flags.ts

A single source-of-truth for feature flag names used across Core and destinations:
FLAGS.ACTIONS_CORE_AUDIENCE_MEMBERSHIP // master gate
FLAGS.ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP
FLAGS.ACTIONS_BRAZE_COHORTS_AUDIENCE_MEMBERSHIP
FLAGS.ACTIONS_LINKEDIN_AUDIENCES_AUDIENCE_MEMBERSHIP
(Facebook Custom Audiences uses only the master flag.)

Updated: destination-kit/types.ts

  • Exports new AudienceMembership = boolean | undefined type
  • Adds optional audienceMembership?: AudienceMembershipType field to ExecuteInput — typed as AudienceMembership for perform and AudienceMembership[] for performBatch

Updated: destination-kit/action.ts

  • When ACTIONS_CORE_AUDIENCE_MEMBERSHIP flag is enabled, calls resolveAudienceMembership on the raw event data and spreads the result into dataBundle for both single and batch execution paths
  • Fixes a latent bug: syncMode values from mapping were not being validated through isSyncMode() before being passed to batch handlers

Updated: index.ts

Exports AudienceMembership, resolveAudienceMembership, and FLAGS for use by destinations.


Destination Changes

Amplitude Cohorts

Flag: ACTIONS_CORE_AUDIENCE_MEMBERSHIP (master flag only — amplitude has no per-destination flag; membership comes from Core unconditionally once the master flag is on)

  • syncAudience/fields.ts: Removed the engage_fields nested object (which contained segment_computation_class, traits_or_properties, segment_audience_key, and segment_external_audience_id). Replaced with a single
    top-level segment_external_audience_id field. This simplifies the mapping and removes the need for each payload to carry audience key / traits data.
  • syncAudience/functions.ts: send() now accepts audienceMemberships?: AudienceMembership[] and uses it to route payloads into addMap / deleteMap. Payloads with undefined membership are individually errored with
    PAYLOAD_VALIDATION_FAILED.
  • syncAudience/index.ts: Destructures audienceMembership from ExecuteInput and passes it to send().

Braze Cohorts

Flags: ACTIONS_CORE_AUDIENCE_MEMBERSHIP AND ACTIONS_BRAZE_COHORTS_AUDIENCE_MEMBERSHIP (both required)

  • syncAudiences/index.ts: Passes audienceMembership and features down to processPayload().
  • syncAudiences/index.ts (extractUsers): When both flags are on, validates the audienceMemberships array (length match, all boolean) and merges membership onto each payload before sort/partition. Uses
    audienceMembership instead of event_properties[personas_audience_key] for add/remove routing. Legacy path preserved when flags are off.

Facebook Custom Audiences

Flag: ACTIONS_CORE_AUDIENCE_MEMBERSHIP (master flag only)

This destination received the most extensive refactor:

  • sync/index.ts: Reduced from ~312 to ~52 lines by extracting field definitions, hook logic, and send logic into dedicated files:
    • sync/fields.ts — all input field definitions
    • sync/hook-functions.ts — performHook for retlOnMappingSave
    • sync/functions.ts — new send() and sendRequest() with audienceMembership-based routing
    • sync/types.ts — PayloadMap, AudienceJSON, FacebookDataRow types
    • sync/constants.ts — US_STATE_CODES, SCHEMA_PROPERTIES, SEGMENT_SCHEMA_PROPERTIES
  • Sync mode default changed from upsert to mirror; mirror added as a first-class choice
  • defaultSubscription added: type = "track" or type = "identify"
  • perform/performBatch now use audienceMembership from Core; syncMode is no longer read in the perform handlers
  • send() builds addMap/deleteMap from audienceMembership values; undefined membership entries are individually errored

Google Enhanced Conversions (userList)

Flags: ACTIONS_CORE_AUDIENCE_MEMBERSHIP AND ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP (both required)

  • userList/index.ts: Sync mode default changed from add to mirror. Destructures audienceMembership and passes it to handleUpdate and processBatchPayload.
  • functions.ts:
    • extractUserIdentifiers — when both flags on, uses audienceMembership instead of syncMode/event_name checks; legacy path preserved otherwise
    • extractBatchUserIdentifiers — passes audienceMemberships[index] to determineOperationType
    • determineOperationType — refactored to return boolean | undefined (was 'add' | 'remove' | null); flag-gated branching between new membership path and legacy event_name/syncMode path

LinkedIn Audiences

Flags: ACTIONS_CORE_AUDIENCE_MEMBERSHIP AND ACTIONS_LINKEDIN_AUDIENCES_AUDIENCE_MEMBERSHIP (both required)

  • updateAudience/index.ts: Destructures audienceMembership and features, passes both to processPayload().
  • updateAudience/functions.ts:
    • validate() — when both flags on, validates audienceMemberships array (array check, length match, all boolean)
    • extractUsers() — passes audienceMemberships[index] per payload to getAction()
    • getAction() — when both flags on, uses audienceMembership in addition to event_name for 'ADD'/'REMOVE' resolution; legacy path preserved

Feature Flags

  • actions-core-audience-membership — master gate; enables audienceMembership injection in Core for all 5 destinations
  • actions-google-ec-audience-membership — per-destination gate for Google Enhanced Conversions
  • actions-braze-cohorts-audience-membership — per-destination gate for Braze Cohorts
  • actions-linkedin-audiences-audience-membership — per-destination gate for LinkedIn Audiences

Amplitude Cohorts and Facebook Custom Audiences use only the master flag. All destinations fall back to their pre-existing behavior when flags are off, ensuring zero regression risk during rollout.


Unit Test Coverage

  • actions-core — New audience-membership.test.ts (362 lines) covering resolveAudienceMembership, engageAudienceMembership, and retlAudienceMembership across all syncModes, event types, and edge cases
  • Amplitude Cohorts — Rewrote syncAudience/tests/index.test.ts; preserved legacy behavior in new snapshot legacy-to-be-removed.test.ts.snap
  • Braze Cohorts — New legacy-to-be-removed.test.ts (842 lines) capturing pre-migration behavior; updated index.test.ts with new membership-based tests
  • Facebook Custom Audiences — Replaced monolithic index.test.ts + fb-operations.test.ts with purpose-built suites: mirror.test.ts (961 lines), upsert.test.ts (408 lines), delete.test.ts (537 lines),
    legacy.test.ts (295 lines), functions.test.ts (342 lines), audience-creation.test.ts (135 lines), canary.test.ts (175 lines)
  • Google Enhanced Conversions — New userList.audienceMembership.test.ts (522 lines) covering flag-on paths; existing userList.test.ts updated for sync mode default change

@joe-ayoub-segment joe-ayoub-segment marked this pull request as ready for review March 11, 2026 13:58
@joe-ayoub-segment joe-ayoub-segment requested a review from a team as a code owner March 11, 2026 13:58
@github-actions
Copy link
Contributor

github-actions bot commented Mar 11, 2026

New required fields detected

Warning

Your PR adds new required fields to an existing destination. Adding new required settings/mappings for a destination already in production requires updating existing customer destination configuration. Ignore this warning if this PR is for a new destination with no active customers in production.

The following required fields were added in this PR:

  • Destination: Amplitude Cohorts, Action Field(s):segment_external_audience_id
  • Destination: Braze Cohorts, Action Field(s):segment_external_audience_id
  • Destination: Facebook Custom Audiences (Actions), Action Field(s):segment_external_audience_id

Add these new fields as optional instead and assume default values in perform or performBatch block.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a first-class audienceMembership signal in the Actions Core execution context (derived from Engage and RETL inputs) and updates the Amplitude Cohorts syncAudience action to rely on that signal instead of passing Engage-specific fields through the action payload.

Changes:

  • Add resolveAudienceMembership helper + AudienceMembership type, and inject audienceMembership into ExecuteInput for perform / performBatch.
  • Update Amplitude Cohorts syncAudience to accept segment_external_audience_id directly and use audienceMembership to decide ADD vs REMOVE.
  • Add unit/integration tests for resolveAudienceMembership and update existing Amplitude Cohorts tests to the new payload shape (partially).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/index.ts Passes audienceMembership through to the action’s send logic.
packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/generated-types.ts Removes engage_fields from payload shape; adds segment_external_audience_id top-level.
packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/functions.ts Uses audienceMembership values to build ADD/REMOVE batches.
packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/fields.ts Updates mapping to only pull segment_external_audience_id from context.personas.
packages/destination-actions/src/destinations/amplitude-cohorts/syncAudience/tests/index.test.ts Updates main test suite payload expectations/mappings for new payload shape.
packages/core/src/index.ts Re-exports AudienceMembership type and resolveAudienceMembership.
packages/core/src/destination-kit/types.ts Adds audienceMembership to ExecuteInput and defines AudienceMembership.
packages/core/src/destination-kit/action.ts Computes and injects audienceMembership in perform / performBatch.
packages/core/src/audience-membership.ts New helper to resolve audience membership from Engage + RETL inputs.
packages/core/src/tests/audience-membership.test.ts New tests for membership resolution and ExecuteInput injection.

Comment on lines 462 to 467
if (this.definition.performBatch) {
const syncMode = this.definition.syncMode ? bundle.mapping?.['__segment_internal_sync_mode'] : undefined
const syncModeVal = this.definition.syncMode ? bundle.mapping?.['__segment_internal_sync_mode'] : undefined
const syncMode = isSyncMode(syncModeVal) ? syncModeVal : undefined
const matchingKey = bundle.mapping?.['__segment_internal_matching_key']
const audienceMembership: AudienceMembership[] = bundle.data.map((d) => resolveAudienceMembership(d, syncMode))

Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In executeBatch, audienceMembership is computed from bundle.data (original raw events) even though payloads may be filtered down after schema validation. If any payloads are dropped as invalid, the membership array will no longer line up with the payload array indices passed into performBatch, causing add/remove decisions to be applied to the wrong payloads. Consider building a filtered audienceMembership array alongside filteredPayload during the validation loop (only pushing membership for indices that remain in payloads).

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +74
it('returns true when the user is being added to the audience', () => {
expect(
resolveAudienceMembership({
context: { personas: { computation_class: 'audience', computation_key: 'my_audience' } },
properties: { my_audience: true }
})
).toBe(true)
})

it('returns false when the user is being removed from the audience', () => {
expect(
resolveAudienceMembership({
context: { personas: { computation_class: 'audience', computation_key: 'my_audience' } },
properties: { my_audience: false }
})
).toBe(false)
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These unit tests call resolveAudienceMembership without an event type, and expect membership to be inferred from properties. The current implementation only resolves Engage membership when type is identify (from traits) or track (from properties), so these assertions will fail. Update the test inputs to include type and put the boolean on traits for identify (or switch to track + properties).

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +128
event: {
type: 'identify',
userId: 'user-1',
context: {
personas: { computation_class: 'audience', computation_key: 'my_audience' }
},
properties: { my_audience: true }
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These integration tests use an identify event with the audience membership boolean in properties, but engageAudienceMembership reads traits[computation_key] for identify events. As written, capturedData.audienceMembership will be undefined and the expectations will fail. Put the boolean on traits for identify (or change the event type to track if you want to keep properties).

Copilot uses AI. Check for mistakes.
Comment on lines 12 to 16
/**
* Fields from the identify() or track() call emitted by Engage. This is used to determine whether the user should be added or removed from the Amplitude Cohort.
* Hidden field containing the Cohort ID which was returned when the Amplitude Cohort was created in the Audience Settings.
*/
engage_fields: {
/**
* Hidden field used to verify that the payload is generated by an Engage Audience. Payloads not containing computation_class = 'audience' or 'journey_step' will be dropped before the perform() fuction call.
*/
segment_computation_class: string
/**
* Traits or Properties object from the identify() or track() call emitted by Engage
*/
traits_or_properties: {
[k: string]: unknown
}
/**
* Hidden field used to determine whether to add or remove the user from the Amplitude Cohort.
*/
segment_audience_key: string
/**
* Hidden field containing the Cohort ID which was returned when the Amplitude Cohort was created in the Audience Settings.
*/
segment_external_audience_id: string
}
segment_external_audience_id: string
/**
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Payload.engage_fields was removed from the generated types and replaced with top-level segment_external_audience_id. There are still existing tests in this action (e.g. __tests__/functions.test.ts and __tests__/getIds.test.ts) that construct Payload objects using engage_fields, which will no longer type-check/compile. Those tests need to be updated to the new payload shape (and any assertions updated accordingly).

Copilot uses AI. Check for mistakes.
@codecov
Copy link

codecov bot commented Mar 12, 2026

Codecov Report

❌ Patch coverage is 76.44231% with 98 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.91%. Comparing base (83cfc2c) to head (b82ddbe).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
...s/facebook-custom-audiences/sync/hook-functions.ts 17.14% 29 Missing ⚠️
...ations/facebook-custom-audiences/sync/functions.ts 83.08% 6 Missing and 17 partials ⚠️
...ons/linkedin-audiences/updateAudience/functions.ts 39.13% 13 Missing and 1 partial ⚠️
...estinations/facebook-custom-audiences/functions.ts 81.66% 7 Missing and 4 partials ⚠️
.../destinations/braze-cohorts/syncAudiences/index.ts 66.66% 6 Missing ⚠️
...ations/amplitude-cohorts/syncAudience/functions.ts 60.00% 4 Missing ⚠️
...rc/destinations/facebook-custom-audiences/index.ts 82.35% 2 Missing and 1 partial ⚠️
...stinations/facebook-custom-audiences/sync/index.ts 66.66% 3 Missing ⚠️
packages/core/src/audience-membership.ts 95.34% 2 Missing ⚠️
...tinations/google-enhanced-conversions/functions.ts 94.11% 0 Missing and 2 partials ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3658      +/-   ##
==========================================
+ Coverage   80.49%   80.91%   +0.42%     
==========================================
  Files        1320     1378      +58     
  Lines       24433    27508    +3075     
  Branches     4987     5906     +919     
==========================================
+ Hits        19667    22259    +2592     
- Misses       3859     4305     +446     
- Partials      907      944      +37     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comment on lines 468 to 473
const audienceMembership: AudienceMembership[] = audienceMembershipEnabled
? bundle.data.map((d) => resolveAudienceMembership(d, syncMode))
: []

const data = {
rawData: bundle.data,
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In executeBatch, payloads can be filtered down when schema validation fails, but audienceMembership is still built from bundle.data (the original, unfiltered events array). That can misalign membership values with payloads indices, causing users to be added/removed incorrectly when any payloads were filtered out. Build audienceMembership (and ideally rawData) from the same filtered set as payloads (e.g., filter bundle.data using invalidPayloadIndices before computing membership and passing rawData into performBatch).

Suggested change
const audienceMembership: AudienceMembership[] = audienceMembershipEnabled
? bundle.data.map((d) => resolveAudienceMembership(d, syncMode))
: []
const data = {
rawData: bundle.data,
// Align rawData and audienceMembership with the filtered payloads by
// removing entries whose indices were marked invalid during schema validation.
const filteredData = Array.isArray(bundle.data)
? bundle.data.filter((_, index) => !invalidPayloadIndices.includes(index))
: bundle.data
const audienceMembership: AudienceMembership[] = audienceMembershipEnabled
? filteredData.map((d) => resolveAudienceMembership(d, syncMode))
: []
const data = {
rawData: filteredData,

Copilot uses AI. Check for mistakes.
Comment on lines 22 to 26
const msResponse = new MultiStatusResponse()

if(!audience_key){
return failAllPayloads(payloads, msResponse, isBatch, 'Segment Audience Key is a required field.')
if(!Array.isArray(audienceMemberships) ){
return failAllPayloads(payloads, msResponse, isBatch, 'Audience membership details missing')
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message used when audienceMemberships is missing/invalid is fairly generic and inconsistent with the later per-payload message ("Audience Membership Details missing"). Since this will surface to customers, consider using a single consistent message and making it actionable (e.g., explain that this action must be triggered by an Engage audience/journey_step computation or a RETL sync event so membership can be determined).

Copilot uses AI. Check for mistakes.
@joe-ayoub-segment joe-ayoub-segment changed the title Draft - Audience membership in actions core Actions Core - Audience membership Mar 12, 2026
Comment on lines 48 to +62
@@ -57,6 +58,8 @@ export interface ExecuteInput<
readonly audienceSettings?: AudienceSettings
/** The transformed input data, based on `mapping` + `event` (or `events` if batched) */
payload: Payload
/** Whether the user is being added to (true) or removed from (false) an audience. Undefined for non-audience events. For batch actions, this is an array matching the payload array. */
audienceMembership?: AudienceMembershipType
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could actually remove AudienceMembershipType = AudienceMembership | AudienceMembership[] and infer from payload if this should be array or not.

audienceMembership?: Payload extends unknown[] ? AudienceMembership[] : AudienceMembership

@joe-ayoub-segment joe-ayoub-segment changed the title Actions Core - Audience membership Audience membership update - Core + multiple destinations Mar 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants